/**
 * EvEmitter v1.0.2
 * Lil' event emitter
 * MIT License
 */

/* jshint unused: true, undef: true, strict: true */

( function( global, factory ) {
    // universal module definition
    /* jshint strict: false */ /* globals define, module */
    if ( typeof define == 'function' && define.amd ) {
        // AMD - RequireJS
        define( factory );
    } else if ( typeof module == 'object' && module.exports ) {
        // CommonJS - Browserify, Webpack
        module.exports = factory();
    } else {
        // Browser globals
        global.EvEmitter = factory();
    }

}( this, function() {

    "use strict";

    function EvEmitter() {}

    var proto = EvEmitter.prototype;

    proto.on = function( eventName, listener ) {
        if ( !eventName || !listener ) {
            return;
        }
        // set events hash
        var events = this._events = this._events || {};
        // set listeners array
        var listeners = events[ eventName ] = events[ eventName ] || [];
        // only add once
        if ( listeners.indexOf( listener ) == -1 ) {
            listeners.push( listener );
        }

        return this;
    };

    proto.once = function( eventName, listener ) {
        if ( !eventName || !listener ) {
            return;
        }
        // add event
        this.on( eventName, listener );
        // set once flag
        // set onceEvents hash
        var onceEvents = this._onceEvents = this._onceEvents || {};
        // set onceListeners object
        var onceListeners = onceEvents[ eventName ] = onceEvents[ eventName ] || {};
        // set flag
        onceListeners[ listener ] = true;

        return this;
    };

    proto.off = function( eventName, listener ) {
        var listeners = this._events && this._events[ eventName ];
        if ( !listeners || !listeners.length ) {
            return;
        }
        var index = listeners.indexOf( listener );
        if ( index != -1 ) {
            listeners.splice( index, 1 );
        }

        return this;
    };

    proto.emitEvent = function( eventName, args ) {
        var listeners = this._events && this._events[ eventName ];
        if ( !listeners || !listeners.length ) {
            return;
        }
        var i = 0;
        var listener = listeners[i];
        args = args || [];
        // once stuff
        var onceListeners = this._onceEvents && this._onceEvents[ eventName ];

        while ( listener ) {
            var isOnce = onceListeners && onceListeners[ listener ];
            if ( isOnce ) {
                // remove listener
                // remove before trigger to prevent recursion
                this.off( eventName, listener );
                // unset once flag
                delete onceListeners[ listener ];
            }
            // trigger listener
            listener.apply( this, args );
            // get next listener
            i += isOnce ? 0 : 1;
            listener = listeners[i];
        }

        return this;
    };

    return EvEmitter;

}));

/*!
 * Unipointer v2.1.0
 * base class for doing one thing with pointer event
 * MIT license
 */

/*jshint browser: true, undef: true, unused: true, strict: true */

( function( window, factory ) {
    // universal module definition
    /* jshint strict: false */ /*global define, module, require */
    if ( typeof define == 'function' && define.amd ) {
        // AMD
        define( [
            'ev-emitter/ev-emitter'
        ], function( EvEmitter ) {
            return factory( window, EvEmitter );
        });
    } else if ( typeof module == 'object' && module.exports ) {
        // CommonJS
        module.exports = factory(
            window,
            require('ev-emitter')
        );
    } else {
        // browser global
        window.Unipointer = factory(
            window,
            window.EvEmitter
        );
    }

}( window, function factory( window, EvEmitter ) {

    'use strict';

    function noop() {}

    function Unipointer() {}

// inherit EvEmitter
    var proto = Unipointer.prototype = Object.create( EvEmitter.prototype );

    proto.bindStartEvent = function( elem ) {
        this._bindStartEvent( elem, true );
    };

    proto.unbindStartEvent = function( elem ) {
        this._bindStartEvent( elem, false );
    };

    /**
     * works as unbinder, as you can ._bindStart( false ) to unbind
     * @param {Boolean} isBind - will unbind if falsey
     */
    proto._bindStartEvent = function( elem, isBind ) {
        // munge isBind, default to true
        isBind = isBind === undefined ? true : !!isBind;
        var bindMethod = isBind ? 'addEventListener' : 'removeEventListener';

        if ( window.navigator.pointerEnabled ) {
            // W3C Pointer Events, IE11. See https://coderwall.com/p/mfreca
            elem[ bindMethod ]( 'pointerdown', this );
        } else if ( window.navigator.msPointerEnabled ) {
            // IE10 Pointer Events
            elem[ bindMethod ]( 'MSPointerDown', this );
        } else {
            // listen for both, for devices like Chrome Pixel
            elem[ bindMethod ]( 'mousedown', this );
            elem[ bindMethod ]( 'touchstart', this );
        }
    };

// trigger handler methods for events
    proto.handleEvent = function( event ) {
        var method = 'on' + event.type;
        if ( this[ method ] ) {
            this[ method ]( event );
        }
    };

// returns the touch that we're keeping track of
    proto.getTouch = function( touches ) {
        for ( var i=0; i < touches.length; i++ ) {
            var touch = touches[i];
            if ( touch.identifier == this.pointerIdentifier ) {
                return touch;
            }
        }
    };

// ----- start event ----- //

    proto.onmousedown = function( event ) {
        // dismiss clicks from right or middle buttons
        var button = event.button;
        if ( button && ( button !== 0 && button !== 1 ) ) {
            return;
        }
        this._pointerDown( event, event );
    };

    proto.ontouchstart = function( event ) {
        this._pointerDown( event, event.changedTouches[0] );
    };

    proto.onMSPointerDown =
        proto.onpointerdown = function( event ) {
            this._pointerDown( event, event );
        };

    /**
     * pointer start
     * @param {Event} event
     * @param {Event or Touch} pointer
     */
    proto._pointerDown = function( event, pointer ) {
        // dismiss other pointers
        if ( this.isPointerDown ) {
            return;
        }

        this.isPointerDown = true;
        // save pointer identifier to match up touch events
        this.pointerIdentifier = pointer.pointerId !== undefined ?
            // pointerId for pointer events, touch.indentifier for touch events
            pointer.pointerId : pointer.identifier;

        this.pointerDown( event, pointer );
    };

    proto.pointerDown = function( event, pointer ) {
        this._bindPostStartEvents( event );
        this.emitEvent( 'pointerDown', [ event, pointer ] );
    };

// hash of events to be bound after start event
    var postStartEvents = {
        mousedown: [ 'mousemove', 'mouseup' ],
        touchstart: [ 'touchmove', 'touchend', 'touchcancel' ],
        pointerdown: [ 'pointermove', 'pointerup', 'pointercancel' ],
        MSPointerDown: [ 'MSPointerMove', 'MSPointerUp', 'MSPointerCancel' ]
    };

    proto._bindPostStartEvents = function( event ) {
        if ( !event ) {
            return;
        }
        // get proper events to match start event
        var events = postStartEvents[ event.type ];
        // bind events to node
        events.forEach( function( eventName ) {
            window.addEventListener( eventName, this );
        }, this );
        // save these arguments
        this._boundPointerEvents = events;
    };

    proto._unbindPostStartEvents = function() {
        // check for _boundEvents, in case dragEnd triggered twice (old IE8 bug)
        if ( !this._boundPointerEvents ) {
            return;
        }
        this._boundPointerEvents.forEach( function( eventName ) {
            window.removeEventListener( eventName, this );
        }, this );

        delete this._boundPointerEvents;
    };

// ----- move event ----- //

    proto.onmousemove = function( event ) {
        this._pointerMove( event, event );
    };

    proto.onMSPointerMove =
        proto.onpointermove = function( event ) {
            if ( event.pointerId == this.pointerIdentifier ) {
                this._pointerMove( event, event );
            }
        };

    proto.ontouchmove = function( event ) {
        var touch = this.getTouch( event.changedTouches );
        if ( touch ) {
            this._pointerMove( event, touch );
        }
    };

    /**
     * pointer move
     * @param {Event} event
     * @param {Event or Touch} pointer
     * @private
     */
    proto._pointerMove = function( event, pointer ) {
        this.pointerMove( event, pointer );
    };

// public
    proto.pointerMove = function( event, pointer ) {
        this.emitEvent( 'pointerMove', [ event, pointer ] );
    };

// ----- end event ----- //


    proto.onmouseup = function( event ) {
        this._pointerUp( event, event );
    };

    proto.onMSPointerUp =
        proto.onpointerup = function( event ) {
            if ( event.pointerId == this.pointerIdentifier ) {
                this._pointerUp( event, event );
            }
        };

    proto.ontouchend = function( event ) {
        var touch = this.getTouch( event.changedTouches );
        if ( touch ) {
            this._pointerUp( event, touch );
        }
    };

    /**
     * pointer up
     * @param {Event} event
     * @param {Event or Touch} pointer
     * @private
     */
    proto._pointerUp = function( event, pointer ) {
        this._pointerDone();
        this.pointerUp( event, pointer );
    };

// public
    proto.pointerUp = function( event, pointer ) {
        this.emitEvent( 'pointerUp', [ event, pointer ] );
    };

// ----- pointer done ----- //

// triggered on pointer up & pointer cancel
    proto._pointerDone = function() {
        // reset properties
        this.isPointerDown = false;
        delete this.pointerIdentifier;
        // remove events
        this._unbindPostStartEvents();
        this.pointerDone();
    };

    proto.pointerDone = noop;

// ----- pointer cancel ----- //

    proto.onMSPointerCancel =
        proto.onpointercancel = function( event ) {
            if ( event.pointerId == this.pointerIdentifier ) {
                this._pointerCancel( event, event );
            }
        };

    proto.ontouchcancel = function( event ) {
        var touch = this.getTouch( event.changedTouches );
        if ( touch ) {
            this._pointerCancel( event, touch );
        }
    };

    /**
     * pointer cancel
     * @param {Event} event
     * @param {Event or Touch} pointer
     * @private
     */
    proto._pointerCancel = function( event, pointer ) {
        this._pointerDone();
        this.pointerCancel( event, pointer );
    };

// public
    proto.pointerCancel = function( event, pointer ) {
        this.emitEvent( 'pointerCancel', [ event, pointer ] );
    };

// -----  ----- //

// utility function for getting x/y coords from event
    Unipointer.getPointerPoint = function( pointer ) {
        return {
            x: pointer.pageX,
            y: pointer.pageY
        };
    };

// -----  ----- //

    return Unipointer;

}));

function FreeSegment( a, b ) {
    this.type = 'FreeSegment';
    this.a = a;
    this.b = b;
    // orientations
    this.noon = {
        a: a,
        b: b
    };
    this.three = {
        a: { x: -a.y, y: a.x },
        b: { x: -b.y, y: b.x }
    };
    this.six = {
        a: { x: -a.x, y: -a.y },
        b: { x: -b.x, y: -b.y }
    };
    this.nine = {
        a: { x: a.y, y: -a.x },
        b: { x: b.y, y: -b.x }
    };
}


var proto = FreeSegment.prototype;

proto.render = function( ctx, center, gridSize ) {
    var ax = this.a.x * gridSize;
    var ay = this.a.y * gridSize;
    var bx = this.b.x * gridSize;
    var by = this.b.y * gridSize;
    ctx.strokeStyle = 'hsla(200, 80%, 50%, 0.7)';
    ctx.lineWidth = gridSize * 0.6;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo( ax, ay );
    ctx.lineTo( bx, by );
    ctx.stroke();
    ctx.closePath();
};


function FixedSegment( a, b ) {
    this.type = 'FixedSegment';
    this.a = a;
    this.b = b;
    // orientations
    this.noon = { a: a, b: b };
    this.three = { a: a, b: b };
    this.six = { a: a, b: b };
    this.nine = { a: a, b: b };
}

var proto = FixedSegment.prototype;

proto.render = function( ctx, center, gridSize ) {
    var ax = this.a.x * gridSize;
    var ay = this.a.y * gridSize;
    var bx = this.b.x * gridSize;
    var by = this.b.y * gridSize;
    ctx.strokeStyle = 'hsla(30, 100%, 40%, 0.6)';
    ctx.lineWidth = gridSize * 0.8;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo( ax, ay );
    ctx.lineTo( bx, by );
    ctx.stroke();
    ctx.closePath();
};

function PivotSegment( a, b ) {
    this.type = 'FreeSegment';
    this.a = a;
    this.b = b;
    var dx = b.x - a.x;
    var dy = b.y - a.y;
    this.delta = { x: dx, y: dy };
    // orientations
    this.noon = {
        a: a,
        b: b
    };
    this.three = {
        a: { x: -a.y, y: a.x },
        b: { x: -a.y + dx, y: a.x + dy }
    };
    this.six = {
        a: { x: -a.x, y: -a.y },
        b: { x: -a.x + dx, y: -a.y + dy }
    };
    this.nine = {
        a: { x: a.y, y: -a.x },
        b: { x: a.y + dx, y: -a.x + dy }
    };
}


var proto = PivotSegment.prototype;

proto.render = function( ctx, center, gridSize, mazeAngle ) {
    var ax = this.a.x * gridSize;
    var ay = this.a.y * gridSize;
    var bx = this.delta.x * gridSize;
    var by = this.delta.y * gridSize;
    ctx.save();

    ctx.translate( ax, ay );
    ctx.rotate( -mazeAngle );
    var color = 'hsla(150, 100%, 35%, 0.7)'
    // line
    ctx.strokeStyle = color;
    ctx.lineWidth = gridSize * 0.4;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo( 0, 0 );
    ctx.lineTo( bx, by );
    ctx.stroke();
    ctx.closePath();
    // circle
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.arc( 0, 0, gridSize * 0.4, 0, Math.PI * 2 );
    ctx.fill();
    ctx.closePath();

    ctx.restore();
};

var TAU = Math.PI * 2;

function RotateSegment( a, b ) {
    this.type = 'RotateSegment';
    this.a = a;
    this.b = b;
    // orientations
    var dx = b.x - a.x;
    var dy = b.y - a.y;
    this.delta = { x: dx, y: dy };
    this.theta = Math.atan2( dy, dx );
    this.noon = { a: a, b: b };
    this.three = { a: a, b: this.getB( TAU/4 ) };
    this.six = { a: a, b: this.getB( TAU/2 ) };
    this.nine = { a: a, b: this.getB( TAU*3/4 ) };
}

var proto = RotateSegment.prototype;

proto.getB = function( angle ) {
    return {
        x: Math.round( this.a.x + Math.cos( this.theta + angle ) * 2 ),
        y: Math.round( this.a.y + Math.sin( this.theta + angle ) * 2 ),
    };
};

proto.render = function( ctx, center, gridSize, mazeAngle ) {
    var ax = this.a.x * gridSize;
    var ay = this.a.y * gridSize;
    ctx.save();
    ctx.translate( ax, ay );
    ctx.rotate( mazeAngle );
    var color = 'hsla(0, 100%, 50%, 0.6)';
    ctx.strokeStyle = color;
    ctx.fillStyle = color;
    // axle
    ctx.lineWidth = gridSize* 0.8;
    ctx.lineJoin = 'round';
    ctx.rotate(TAU/8);
    ctx.strokeRect( -gridSize*0.2, -gridSize*0.2, gridSize*0.4, gridSize*0.4 );
    ctx.rotate(-TAU/8);
    // line
    ctx.lineWidth = gridSize * 0.8;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo( 0, 0 );

    var bx = this.delta.x * gridSize;
    var by = this.delta.y * gridSize;
    ctx.lineTo( bx, by );
    ctx.stroke();
    ctx.closePath();
    ctx.restore();
};

// rotational physics model

var TAU = Math.PI * 2;

function FlyWheel( props ) {
    this.angle = 0;
    this.friction = 0.95;
    this.velocity = 0;

    for ( var prop in props ) {
        this[ prop ] = props[ prop ];
    }
}

var proto = FlyWheel.prototype;

proto.integrate = function() {
    this.velocity *= this.friction;
    this.angle += this.velocity;
    this.normalizeAngle();
};

proto.applyForce = function( force ) {
    this.velocity += force;
};

proto.normalizeAngle = function() {
    this.angle = ( ( this.angle % TAU ) + TAU ) % TAU;
};

proto.setAngle = function( theta ) {
    var velo = theta - this.angle;
    if ( velo > TAU/2 ) {
        velo -= TAU;
    } else if ( velo < -TAU/2 ) {
        velo += TAU;
    }
    var force = velo - this.velocity;
    this.applyForce( force );
};


var cub = {
    offset: { x: 0, y: 0 },
};

var pegOrienter = {
    noon: function( peg ) {
        return peg;
    },
    three: function( peg ) {
        return { x: peg.y, y: -peg.x };
    },
    six: function( peg ) {
        return { x: -peg.x, y: -peg.y };
    },
    nine: function( peg ) {
        return { x: -peg.y, y: peg.x };
    },
};

cub.setPeg = function( peg, orientation ) {
    peg = pegOrienter[ orientation ]( peg );
    this.peg = peg;

    this.noon = { x: peg.x, y: peg.y };
    this.three = { x: -peg.y, y: peg.x };
    this.six = { x: -peg.x, y: -peg.y };
    this.nine = { x: peg.y, y: -peg.x };
};

var offsetOrienter = {
    noon: function( offset ) {
        return offset;
    },
    three: function( offset ) {
        // flip y because its rendering
        return { x: offset.y, y: -offset.x };
    },
    six: function( offset ) {
        return { x: -offset.x, y: -offset.y };
    },
    nine: function( offset ) {
        // flip y because its rendering
        return { x: -offset.y, y: offset.x };
    },
};

cub.setOffset = function( offset, orientation ) {
    this.offset = offsetOrienter[ orientation ]( offset );
};

// ----- render ----- //

cub.render = function( ctx, mazeCenter, gridSize, angle, isHovered ) {
    function circle( x, y, radius ) {
        ctx.beginPath();
        ctx.arc( x, y, radius, 0, Math.PI * 2 );
        ctx.fill();
        ctx.closePath();
    }

    var x = this.peg.x * gridSize + this.offset.x;
    var y = this.peg.y * gridSize + this.offset.y;
    ctx.save();
    ctx.translate( mazeCenter.x, mazeCenter.y );
    ctx.rotate( angle );
    ctx.translate( x, y );
    ctx.rotate( -angle );
    ctx.fillStyle = 'hsla(330, 100%, 40%, 1)';
    var scale = isHovered ? 1.15 : 1;
    ctx.scale( scale, scale );
    circle( 0, 0, gridSize * 0.6 );
    circle( gridSize * -0.45, gridSize * -0.35, gridSize * 0.3 );
    circle( gridSize * 0.45, gridSize * -0.35, gridSize * 0.3 );

    ctx.restore();
};


/* globals FlyWheel, FreeSegment, FixedSegment, PivotSegment, RotateSegment, cub */

function Maze() {
    this.freeSegments = [];
    this.fixedSegments = [];
    this.pivotSegments = [];
    this.rotateSegments = [];
    this.flyWheel = new FlyWheel({
        friction: 0.8
    });
    this.connections = {};
}

var proto = Maze.prototype;

proto.loadText = function( text ) {
    // separate --- sections, YAML front matter first, maze source second;
    var sections = text.split('---\n');
    // YAML front matter
    var frontMatter = {};
    if ( sections.length > 1 ) {
        frontMatter = getFrontMatter( sections[0] );
    }
    // set instruction
    var instructElem = document.querySelector('.instruction');
    instructElem.innerHTML = frontMatter.instruction || '';

    var mazeSrc = sections[ sections.length - 1 ];
    var lines = mazeSrc.split('\n');
    var gridCount = this.gridCount = lines[0].length;
    var gridMax = this.gridMax = ( gridCount - 1 ) / 2;

    for ( var i=0; i < lines.length; i++ ) {
        var line = lines[i];
        var chars = line.split('');
        for ( var j=0; j < chars.length; j++ ) {
            var character = chars[j];
            var pegX = j - gridMax;
            var pegY = i - gridMax;
            var parseMethod = 'parse' + character;
            if ( this[ parseMethod ] ) {
                this[ parseMethod ]( pegX, pegY );
            }
        }
    }
};

function getFrontMatter( text ) {
    if ( !text ) {
        return;
    }
    var frontMatter = {};
    text.split('\n').forEach( function( line ) {
        if ( !line ) {
            return;
        }
        var parts = line.split(':');
        var key = parts[0].trim();
        var value = parts[1].trim();
        if ( value === 'true' ) {
            value = true; // boolean true
        } else if ( value === 'false' ) {
            value = false; // boolean false
        } else if ( value.match(/$\d+(\.\d+)?^/) ) {
            value = parseFloat( value, 10 ); // number
        } else if ( value.match(/$\d+\.\d+^/) ) {
            value = parseFloat( value ); // float
        }
        frontMatter[ key ] = value;
    });
    return frontMatter;
}


// -------------------------- parsers -------------------------- //

// horizontal free segment
proto['parse-'] = proto.addFreeHorizSegment = function( pegX, pegY ) {
    var segment = getHorizSegment( pegX, pegY, FreeSegment );
    this.connectSegment( segment );
    this.freeSegments.push( segment );
};

// vertical free segment
proto['parse|'] = proto.addFreeVertSegment = function( pegX, pegY ) {
    var segment = getVertSegment( pegX, pegY, FreeSegment );
    this.connectSegment( segment );
    this.freeSegments.push( segment );
};

// horizontal fixed segment
proto['parse='] = proto.addFixedHorizSegment = function( pegX, pegY ) {
    var segment = getHorizSegment( pegX, pegY, FixedSegment );
    this.connectSegment( segment );
    this.fixedSegments.push( segment );
};

// vertical fixed segment
proto['parse!'] = proto.addFixedVertSegment = function( pegX, pegY ) {
    var segment = getVertSegment( pegX, pegY, FixedSegment );
    this.connectSegment( segment );
    this.fixedSegments.push( segment );
};

function getHorizSegment( pegX, pegY, Segment ) {
    var a = { x: pegX + 1, y: pegY };
    var b = { x: pegX - 1, y: pegY };
    return new Segment( a, b );
}

function getVertSegment( pegX, pegY, Segment ) {
    var a = { x: pegX, y: pegY + 1 };
    var b = { x: pegX, y: pegY - 1 };
    return new Segment( a, b );
}

// ----- pivot ----- //

// pivot up segment
proto['parse^'] = proto.addPivotUpSegment = function( pegX, pegY ) {
    var a = { x: pegX, y: pegY + 1 };
    var b = { x: pegX, y: pegY - 1 };
    var segment = new PivotSegment( a, b );
    this.connectSegment( segment );
    this.pivotSegments.push( segment );
};

// pivot down segment
proto.parsev = proto.addPivotDownSegment = function( pegX, pegY ) {
    var a = { x: pegX, y: pegY - 1 };
    var b = { x: pegX, y: pegY + 1 };
    var segment = new PivotSegment( a, b );
    this.connectSegment( segment );
    this.pivotSegments.push( segment );
};

// pivot left segment
proto['parse<'] = proto.addPivotLeftSegment = function( pegX, pegY ) {
    var a = { x: pegX + 1, y: pegY };
    var b = { x: pegX - 1, y: pegY };
    var segment = new PivotSegment( a, b );
    this.connectSegment( segment );
    this.pivotSegments.push( segment );
};

// pivot right segment
proto['parse>'] = proto.addPivotRightSegment = function( pegX, pegY ) {
    var a = { x: pegX - 1, y: pegY };
    var b = { x: pegX + 1, y: pegY };
    var segment = new PivotSegment( a, b );
    this.connectSegment( segment );
    this.pivotSegments.push( segment );
};

// ----- rotate ----- //

proto.parse8 = proto.addRotateUpSegment = function( pegX, pegY ) {
    var a = { x: pegX, y: pegY + 1 };
    var b = { x: pegX, y: pegY - 1 };
    var segment = new RotateSegment( a, b );
    this.connectSegment( segment );
    this.rotateSegments.push( segment );
};

proto.parse4 = proto.addRotateLeftSegment = function( pegX, pegY ) {
    var a = { x: pegX + 1, y: pegY };
    var b = { x: pegX - 1, y: pegY };
    var segment = new RotateSegment( a, b );
    this.connectSegment( segment );
    this.rotateSegments.push( segment );
};

proto.parse5 = proto.addRotateUpSegment = function( pegX, pegY ) {
    var a = { x: pegX, y: pegY - 1 };
    var b = { x: pegX, y: pegY + 1 };
    var segment = new RotateSegment( a, b );
    this.connectSegment( segment );
    this.rotateSegments.push( segment );
};

proto.parse6 = proto.addRotateRightSegment = function( pegX, pegY ) {
    var a = { x: pegX - 1, y: pegY };
    var b = { x: pegX + 1, y: pegY };
    var segment = new RotateSegment( a, b );
    this.connectSegment( segment );
    this.rotateSegments.push( segment );
};

// ----- combos ----- //

// free & fixed horizontal
proto['parse#'] = function( pegX, pegY ) {
    this.addFreeHorizSegment( pegX, pegY );
    this.addFixedHorizSegment( pegX, pegY );
};

// free & fixed vertical
proto.parse$ = function( pegX, pegY ) {
    this.addFreeVertSegment( pegX, pegY );
    this.addFixedVertSegment( pegX, pegY );
};

// pivot up + fixed vertical
proto.parseI = function( pegX, pegY ) {
    this.addPivotUpSegment( pegX, pegY );
    this.addFixedVertSegment( pegX, pegY );
};

// pivot left + fixed horizontal
proto.parseJ = function( pegX, pegY ) {
    this.addPivotLeftSegment( pegX, pegY );
    this.addFixedHorizSegment( pegX, pegY );
};

// pivot down + fixed vertical
proto.parseK = function( pegX, pegY ) {
    this.addPivotDownSegment( pegX, pegY );
    this.addFixedVertSegment( pegX, pegY );
};

// pivot right + fixed horizontal
proto.parseL = function( pegX, pegY ) {
    this.addPivotRightSegment( pegX, pegY );
    this.addFixedHorizSegment( pegX, pegY );
};

// pivot up + free vertical
proto.parseW = function( pegX, pegY ) {
    this.addPivotUpSegment( pegX, pegY );
    this.addFreeVertSegment( pegX, pegY );
};

// pivot left + free horizontal
proto.parseA = function( pegX, pegY ) {
    this.addPivotLeftSegment( pegX, pegY );
    this.addFreeHorizSegment( pegX, pegY );
};

// pivot down + free vertical
proto.parseS = function( pegX, pegY ) {
    this.addPivotDownSegment( pegX, pegY );
    this.addFreeVertSegment( pegX, pegY );
};

// pivot right + free horizontal
proto.parseD = function( pegX, pegY ) {
    this.addPivotRightSegment( pegX, pegY );
    this.addFreeHorizSegment( pegX, pegY );
};

// start position
proto['parse@'] = function( pegX, pegY ) {
    this.startPosition = { x: pegX, y: pegY };
    cub.setPeg( this.startPosition, 'noon' );
};

// goal position
proto['parse*'] = function( pegX, pegY ) {
    this.goalPosition = { x: pegX, y: pegY };
};

// --------------------------  -------------------------- //

proto.updateItemGroups = function() {
    var itemGroups = {};
    this.items.forEach( function( item ) {
        if ( itemGroups[ item.type ] === undefined ) {
            itemGroups[ item.type ] = [];
        }
        itemGroups[ item.type ].push( item );
    });
    this.itemGroups = itemGroups;
};

var orientations = [ 'noon', 'three', 'six', 'nine' ];

proto.connectSegment = function( segment ) {
    orientations.forEach( function( orientation ) {
        var line = segment[ orientation ];
        // check that pegs are not out of maze
        if ( this.getIsPegOut( line.a ) || this.getIsPegOut( line.b ) ) {
            return;
        }
        this.connectPeg( segment, orientation, line.a );
        this.connectPeg( segment, orientation, line.b );
    }, this );
};

proto.getIsPegOut = function( peg ) {
    return Math.abs( peg.x ) > this.gridMax ||
        Math.abs( peg.y ) > this.gridMax;
};

proto.connectPeg = function( segment, orientation, peg ) {
    // flatten the key
    var key = orientation + ':' + peg.x + ',' + peg.y;
    var connection = this.connections[ key ];
    // create connections array if not already there
    if ( !connection ) {
        connection = this.connections[ key ] = [];
    }
    if ( connection.indexOf( segment ) == -1 ) {
        connection.push( segment );
    }
};

// --------------------------  -------------------------- //

proto.update = function() {
    this.flyWheel.integrate();
    var angle = this.flyWheel.angle;
    if ( angle < TAU/8 ) {
        this.orientation = 'noon';
    } else if ( angle < TAU * 3/8 ) {
        this.orientation = 'three';
    } else if ( angle < TAU * 5/8 ) {
        this.orientation = 'six';
    } else if ( angle < TAU * 7/8 ) {
        this.orientation = 'nine';
    } else {
        this.orientation = 'noon';
    }
};

proto.attractAlignFlyWheel = function() {
    // attract towards
    var angle = this.flyWheel.angle;
    var target;
    if ( angle < TAU/8 ) {
        target = 0;
    } else if ( angle < TAU * 3/8 ) {
        target = TAU/4;
    } else if ( angle < TAU * 5/8 ) {
        target = TAU/2;
    } else if ( angle < TAU * 7/8 ) {
        target = TAU * 3/4;
    } else {
        target = TAU;
    }
    var attraction = ( target - angle ) * 0.03;
    this.flyWheel.applyForce( attraction );
};

var TAU = Math.PI * 2;

var orientationAngles = {
    noon: 0,
    three: TAU/4,
    six: TAU/2,
    nine: TAU * 3/4
};

proto.render = function( ctx, center, gridSize, angle ) {
    var orientationAngle = orientationAngles[ angle ];
    var gridMax = this.gridMax;
    angle = orientationAngle !== undefined ? orientationAngle : angle || 0;


    ctx.save();
    ctx.translate( center.x, center.y );
    // fixed segments
    this.fixedSegments.forEach( function( segment ) {
        segment.render( ctx, center, gridSize );
    });
    // rotate segments
    this.rotateSegments.forEach( function( segment ) {
        segment.render( ctx, center, gridSize, angle );
    });
    // rotation
    ctx.rotate( angle );

    ctx.lineWidth = gridSize * 0.2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    // axle
    ctx.lineWidth = gridSize * 0.2;
    ctx.strokeStyle = 'hsla(0, 0%, 50%, 0.2)';
    // strokeCircle( ctx, 0, 0, gridSize/2 );
    ctx.save();
    ctx.rotate( Math.PI/4 );
    ctx.strokeRect( -gridSize/5, -gridSize/5, gridSize*2/5, gridSize*2/5 );
    ctx.restore();
    // start position
    ctx.strokeStyle = 'hsla(330, 100%, 50%, 0.3)';
    ctx.lineWidth = gridSize * 0.15;
    var startX = this.startPosition.x * gridSize;
    var startY = this.startPosition.y * gridSize;
    strokeCircle( ctx, startX, startY, gridSize * 0.5 );

    // pegs
    for ( var pegY = -gridMax; pegY <= gridMax; pegY += 2 ) {
        for ( var pegX = -gridMax; pegX <= gridMax; pegX += 2 ) {
            var pegXX = pegX * gridSize;
            var pegYY = pegY * gridSize;
            ctx.fillStyle = 'hsla(0, 0%, 50%, 0.6)';
            fillCircle( ctx, pegXX, pegYY, gridSize * 0.15 );
        }
    }
    // free segments
    this.freeSegments.forEach( function( segment ) {
        segment.render( ctx, center, gridSize );
    });
    // pivot segments
    this.pivotSegments.forEach( function( segment ) {
        segment.render( ctx, center, gridSize, angle );
    });
    // goal position
    var goalX = this.goalPosition.x * gridSize;
    var goalY = this.goalPosition.y * gridSize;
    ctx.lineWidth = gridSize * 0.3;
    ctx.fillStyle = 'hsla(50, 100%, 50%, 1)';
    ctx.strokeStyle = 'hsla(50, 100%, 50%, 1)';
    renderGoal( ctx, goalX, goalY, angle, gridSize * 0.6, gridSize * 0.3 );

    ctx.restore();
};

function fillCircle( ctx, x, y, radius ) {
    ctx.beginPath();
    ctx.arc( x, y, radius, 0, Math.PI * 2 );
    ctx.fill();
    ctx.closePath();
}

function strokeCircle( ctx, x, y, radius ) {
    ctx.beginPath();
    ctx.arc( x, y, radius, 0, Math.PI * 2 );
    ctx.stroke();
    ctx.closePath();
}

function renderGoal( ctx, x, y, mazeAngle, radiusA, radiusB ) {
    ctx.save();
    ctx.translate( x, y );
    ctx.rotate( -mazeAngle );
    ctx.beginPath();
    for ( var i=0; i<11; i++ ) {
        var theta = Math.PI*2 * i/10 + Math.PI/2;
        var radius = i % 2 ? radiusA : radiusB;
        var dx = Math.cos( theta ) * radius;
        var dy = Math.sin( theta ) * radius;
        ctx[ i ? 'lineTo' : 'moveTo' ]( dx, dy );
    }
    ctx.fill();
    ctx.stroke();
    ctx.closePath();
    ctx.restore();
}


function WinAnimation( x, y ) {
    this.x = x;
    this.y = y;
    this.startTime = new Date();
    this.isPlaying = true;
}

// length of animation in milliseconds
var duration = 1000;

var proto = WinAnimation.prototype;

proto.update = function() {
    if ( !this.isPlaying ) {
        return;
    }
    this.t = ( ( new Date() ) - this.startTime ) / duration;
    this.isPlaying = this.t <= 1;
};

proto.render = function( ctx ) {
    if ( !this.isPlaying ) {
        return;
    }

    ctx.save();
    ctx.translate( this.x, this.y );

    // big burst
    this.renderBurst( ctx );
    // small burst
    ctx.save();
    ctx.scale( 0.5, -0.5 );
    this.renderBurst( ctx );
    ctx.restore();

    ctx.restore();
};

proto.renderBurst = function( ctx ) {

    var t = this.t;
    var dt = 1 - t;
    var easeT = 1 - dt*dt*dt*dt*dt*dt*dt*dt;
    var dy = easeT * -100;
    // scale math
    var st = 2 - this.t*2;
    var scale = (1-t*t*t) * 1.5;
    var spin = Math.PI * 1 * t*t*t;

    for ( var i=0; i<5; i++ ) {
        ctx.save();
        ctx.rotate( Math.PI * 2/5 * i );
        ctx.translate( 0, dy );
        ctx.scale( scale, scale );
        ctx.rotate( spin );
        renderStar( ctx );
        ctx.restore();
    }
};

function renderStar( ctx ) {
    ctx.lineWidth = 8;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.fillStyle = 'hsla(50, 100%, 50%, 1)';
    ctx.strokeStyle = 'hsla(50, 100%, 50%, 1)';
    ctx.beginPath();
    for ( var i=0; i<11; i++ ) {
        var theta = Math.PI*2 * i/10 + Math.PI/2;
        var radius = i % 2 ? 20 : 10;
        var dx = Math.cos( theta ) * radius;
        var dy = Math.sin( theta ) * radius;
        ctx[ i ? 'lineTo' : 'moveTo' ]( dx, dy );
    }
    ctx.fill();
    ctx.stroke();
    ctx.closePath();
}

/* globals cub, WinAnimation, Unipointer, Maze */

var docElem = document.documentElement;
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
// size canvas;
var canvasSize = Math.min( window.innerWidth, window.innerHeight );
var canvasWidth = canvas.width = window.innerWidth * 2;
var canvasHeight = canvas.height = window.innerHeight * 2;
var maze;
var PI = Math.PI;
var TAU = PI * 2;
var dragAngle = null;
var cubDragMove = null;
var isCubHovered = false;
var isCubDragging = false;
var winAnim;
var unipointer = new Unipointer();

// ----- config ----- //

var gridSize = Math.min( 40, canvasSize/12 );
var mazeCenter = {
    x: canvasWidth/4,
    y: Math.min( gridSize * 8, canvasHeight/4 )
};

// ----- instruction ----- //

var instructElem = document.querySelector('.instruction');
instructElem.style.top = ( mazeCenter.y + gridSize * 5.5 ) + 'px';

// ----- build level select, levels array ----- //

var levelList = document.querySelector('.level-list');
var levelsElem = document.querySelector('.levels');
var levels = [];

(function() {
    var levelPres = levelsElem.querySelectorAll('pre');
    var fragment = document.createDocumentFragment();
    for ( var i=0; i < levelPres.length; i++ ) {
        var pre = levelPres[i];
        var listItem = document.createElement('li');
        listItem.className = 'level-list__item';
        var id = pre.id;
        listItem.innerHTML = '<span class="level-list__item__number">' + ( i + 1 ) +
            '</span> <span class="level-list__item__blurb">' +
            pre.getAttribute('data-blurb') + '</span>' +
            '<span class="level-list__item__check">✔</span>';
        listItem.setAttribute( 'data-id', id );
        fragment.appendChild( listItem );
        levels.push( id );
    }

    levelList.appendChild( fragment );

})();

// ----- levels button ----- //

var levelSelectButton = document.querySelector('.level-select-button');
var nextLevelButton = document.querySelector('.next-level-button');

levelSelectButton.addEventListener( 'click', function() {
    levelList.classList.add('is-open');
});

nextLevelButton.style.top = ( mazeCenter.y + gridSize * 5.5 ) + 'px';

// ----- level list ----- //

levelList.addEventListener( 'click', function( event ) {
    var item = getParent( event.target, '.level-list__item' );
    if ( !item ) {
        return;
    }
    // load level from id
    var id = item.getAttribute('data-id');
    loadLevel( id );
});

function getParent( elem, selector ) {
    var parent = elem;
    while ( parent != document.body ) {
        if ( parent.matches( selector ) ) {
            return parent;
        }
        parent = parent.parentNode;
    }
}

// ----- load level ----- //

function loadLevel( id ) {
    var pre = levelsElem.querySelector( '#' + id );

    maze = new Maze();
    maze.id = id;

    if ( !pre ) {
        console.error( 'pre not found for ' + id );
        return;
    }

    // load maze level from pre text
    maze.loadText( pre.textContent );
    // close ui
    levelList.classList.remove('is-open');
    nextLevelButton.classList.remove('is-open');
    window.scrollTo( 0, 0 );
    // highlight list
    var previousItem = levelList.querySelector('.is-playing');
    if ( previousItem ) {
        previousItem.classList.remove('is-playing');
    }
    levelList.querySelector('[data-id="' + id + '"]').classList.add('is-playing');
    localStorage.setItem( 'currentLevel', id );
}

// ----- init ----- //

var initialLevel = localStorage.getItem('currentLevel') || levels[0];
loadLevel( initialLevel );

unipointer.bindStartEvent( canvas );
window.addEventListener( 'mousemove', onHoverMousemove );
animate();

// -------------------------- drag rotation -------------------------- //

var canvasLeft = canvas.offsetLeft;
var canvasTop = canvas.offsetTop;

var pointerBehavior;

// ----- pointerBehavior ----- //

var cubDrag = {};
var mazeRotate = {};

// -----  ----- //

unipointer.pointerDown = function( event, pointer ) {
    event.preventDefault();
    var isInsideCub = getIsInsideCub( pointer );
    pointerBehavior = isInsideCub ? cubDrag : mazeRotate;

    pointerBehavior.pointerDown( event, pointer );

    this._bindPostStartEvents( event );
};

function getIsInsideCub( pointer ) {
    var position = getCanvasMazePosition( pointer );
    var cubDeltaX = Math.abs( position.x - cub[ maze.orientation ].x * gridSize );
    var cubDeltaY = Math.abs( position.y - cub[ maze.orientation ].y * gridSize );
    var bound = gridSize * 1.5;
    return cubDeltaX <= bound && cubDeltaY <= bound;
}

function getCanvasMazePosition( pointer ) {
    var canvasX = pointer.pageX - canvasLeft;
    var canvasY = pointer.pageY - canvasTop;
    return {
        x: canvasX - mazeCenter.x,
        y: canvasY - mazeCenter.y,
    };
}

// ----- unipointer ----- //

unipointer.pointerMove = function( event, pointer ) {
    pointerBehavior.pointerMove( event, pointer );
};

unipointer.pointerUp = function( event, pointer ) {
    pointerBehavior.pointerUp( event, pointer );
    this._unbindPostStartEvents();
};

// ----- cubDrag ----- //

var dragStartPosition, dragStartPegPosition, rotatePointer;

cubDrag.pointerDown = function( event, pointer ) {
    var segments = getCubConnections();
    if ( !segments || !segments.length ) {
        return;
    }
    isCubDragging = true;
    dragStartPosition = { x: pointer.pageX, y: pointer.pageY };
    dragStartPegPosition = {
        x: cub[ maze.orientation ].x * gridSize + mazeCenter.x,
        y: cub[ maze.orientation ].y * gridSize + mazeCenter.y,
    };
    docElem.classList.add('is-cub-dragging');
};

cubDrag.pointerMove = function( event, pointer ) {
    if ( !isCubDragging ) {
        return;
    }
    cubDragMove = {
        x: pointer.pageX - dragStartPosition.x,
        y: pointer.pageY - dragStartPosition.y,
    };
};

cubDrag.pointerUp = function() {
    cubDragMove = null;
    docElem.classList.remove('is-cub-dragging');
    isCubDragging = false;
    // set at peg
    cub.setOffset( { x: 0, y: 0 }, maze.orientation );
    // check level complete
    if ( cub.peg.x == maze.goalPosition.x && cub.peg.y == maze.goalPosition.y ) {
        completeLevel();
        console.log('win');
    }
};

// ----- rotate ----- //

var dragStartAngle, dragStartMazeAngle, moveAngle;
var mazeRotate = {};


mazeRotate.pointerDown = function( event, pointer ) {
    dragStartAngle = moveAngle = getDragAngle( pointer );
    dragStartMazeAngle = maze.flyWheel.angle;
    dragAngle = dragStartMazeAngle;
    rotatePointer = pointer;
};

function getDragAngle( pointer ) {
    var position = getCanvasMazePosition( pointer );
    return normalizeAngle( Math.atan2( position.y, position.x ) );
}

mazeRotate.pointerMove = function( event, pointer ) {
    rotatePointer = pointer;
    moveAngle = getDragAngle( pointer );
    var deltaAngle = moveAngle - dragStartAngle;
    dragAngle = normalizeAngle( dragStartMazeAngle + deltaAngle );
};

mazeRotate.pointerUp = function() {
    dragAngle = null;
    rotatePointer = null;
};


// ----- animate ----- //

function animate() {
    update();
    render();
    requestAnimationFrame( animate );
}

// ----- update ----- //

function update() {
    // drag cub
    dragCub();
    // rotate grid
    if ( dragAngle ) {
        maze.flyWheel.setAngle( dragAngle );
    } else {
        maze.attractAlignFlyWheel();
    }
    maze.update();
    if ( winAnim ) {
        winAnim.update();
    }
}

function dragCub() {
    if ( !cubDragMove ) {
        return;
    }

    var segments = getCubConnections();

    var dragPosition = {
        x: dragStartPegPosition.x + cubDragMove.x,
        y: dragStartPegPosition.y + cubDragMove.y,
    };

    // set peg position
    var dragPeg = getDragPeg( segments, dragPosition );
    cub.setPeg( dragPeg, maze.orientation );

    // set drag offset
    var cubDragPosition = getDragPosition( segments, dragPosition );

    var cubPosition = getCubPosition();
    var offset = {
        x: cubDragPosition.x - cubPosition.x,
        y: cubDragPosition.y - cubPosition.y,
    };
    cub.setOffset( offset, maze.orientation );

}

function getCubPosition() {
    return {
        x: cub[ maze.orientation ].x * gridSize + mazeCenter.x,
        y: cub[ maze.orientation ].y * gridSize + mazeCenter.y,
    };
}

function getCubConnections() {
    var pegX = cub[ maze.orientation ].x;
    var pegY = cub[ maze.orientation ].y;
    var key = maze.orientation + ':' + pegX + ',' + pegY;
    return maze.connections[ key ];
}

function getDragPosition( segments, dragPosition ) {
    if ( segments.length == 1 ) {
        return getSegmentDragPosition( segments[0], dragPosition );
    }

    // get closest segments positions
    var dragCandidates = segments.map( function( segment ) {
        var position = getSegmentDragPosition( segment, dragPosition );
        return {
            position: position,
            distance: getDistance( dragPosition, position ),
        };
    });

    dragCandidates.sort( distanceSorter);

    return dragCandidates[0].position;
}

function getSegmentDragPosition( segment, dragPosition ) {
    var line = segment[ maze.orientation ];
    var isHorizontal = line.a.y == line.b.y;
    var x, y;
    if ( isHorizontal ) {
        x = getSegmentDragCoord( line, 'x', dragPosition );
        y = line.a.y * gridSize + mazeCenter.y;
    } else {
        x = line.a.x * gridSize + mazeCenter.x;
        y = getSegmentDragCoord( line, 'y', dragPosition );
    }
    return { x: x, y: y };
}

function getSegmentDragCoord( line, axis, dragPosition ) {
    var a = line.a[ axis ];
    var b = line.b[ axis ];
    var min = a < b ? a : b;
    var max = a > b ? a : b;
    min = min * gridSize + mazeCenter[ axis ];
    max = max * gridSize + mazeCenter[ axis ];
    return Math.max( min, Math.min( max, dragPosition[ axis ] ) );
}

function distanceSorter( a, b ) {
    return a.distance - b.distance;
}

function getDragPeg( segments, dragPosition ) {
    var pegs = [];
    segments.forEach( function( segment ) {
        var line = segment[ maze.orientation ];
        addPegPoint( line.a, pegs );
        addPegPoint( line.b, pegs );
    });

    var pegCandidates = pegs.map( function( pegKey ) {
        // revert string back to object with integers
        var parts = pegKey.split(',');
        var peg = {
            x: parseInt( parts[0], 10 ),
            y: parseInt( parts[1], 10 ),
        };
        var pegPosition = {
            x: peg.x * gridSize + mazeCenter.x,
            y: peg.y * gridSize + mazeCenter.y,
        };
        return {
            peg: peg,
            distance: getDistance( dragPosition, pegPosition ),
        };
    });

    pegCandidates.sort( distanceSorter );

    return pegCandidates[0].peg;
}

function getDistance( a, b ) {
    var dx = b.x - a.x;
    var dy = b.y - a.y;
    return Math.sqrt( dx * dx + dy * dy );
}

function addPegPoint( point, pegs ) {
    // use strings to prevent dupes
    var key = point.x + ',' + point.y;
    if ( pegs.indexOf( key ) == -1 ) {
        pegs.push( key );
    }
}

// ----- hover ----- //

function onHoverMousemove( event ) {
    var isInsideCub = getIsInsideCub( event );
    if ( isInsideCub == isCubHovered ) {
        return;
    }
    // change
    isCubHovered = isInsideCub;
    var changeClass = isInsideCub ? 'add' : 'remove';
    docElem.classList[ changeClass ]('is-cub-hovered');
}

// ----- render ----- //

function render() {
    ctx.clearRect( 0, 0, canvasWidth, canvasHeight );
    ctx.save();
    ctx.scale( 2, 2 );
    renderRotateHandle();
    // maze
    maze.render( ctx, mazeCenter, gridSize, maze.flyWheel.angle );
    // win animation
    if ( winAnim ) {
        winAnim.render( ctx );
    }
    // cub
    var isHovered = isCubHovered || isCubDragging;
    cub.render( ctx, mazeCenter, gridSize, maze.flyWheel.angle, isHovered );
    ctx.restore();
}

function renderRotateHandle() {
    // rotate handle
    if ( !rotatePointer ) {
        return;
    }

    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.lineWidth = gridSize * 0.5;
    var color = '#EEE';
    ctx.strokeStyle = color;
    ctx.fillStyle = color;

    // pie slice
    ctx.beginPath();
    var pieRadius = maze.gridMax * gridSize;
    ctx.moveTo( mazeCenter.x, mazeCenter.y );
    var pieDirection = normalizeAngle( normalizeAngle( moveAngle ) -
        normalizeAngle( dragStartAngle ) ) > TAU/2 ;
    ctx.arc( mazeCenter.x, mazeCenter.y, pieRadius, dragStartAngle, moveAngle, pieDirection );
    ctx.lineTo( mazeCenter.x, mazeCenter.y );
    ctx.stroke();
    ctx.fill();
    ctx.closePath();
}

// -------------------------- completeLevel -------------------------- //

var completedLevels = localStorage.getItem('completedLevels');
completedLevels = completedLevels ? completedLevels.split(',') : [];

completedLevels.forEach( function( id ) {
    var item = levelList.querySelector('[data-id="' + id + '"]');
    if ( item ) {
        item.classList.add('did-complete');
    }
});

function completeLevel() {
    var cubPosition = getCubPosition();
    winAnim = new WinAnimation( cubPosition.x, cubPosition.y );
    levelList.querySelector('[data-id="' + maze.id + '"]').classList.add('did-complete');
    if ( completedLevels.indexOf( maze.id ) == -1 ) {
        completedLevels.push( maze.id );
        localStorage.setItem( 'completedLevels', completedLevels.join(',') );
    }
    if ( getNextLevel() ) {
        setTimeout( function() {
            nextLevelButton.classList.add('is-open');
        }, 1000 );
    }
}

function getNextLevel() {
    var index = levels.indexOf( maze.id );
    return levels[ index + 1 ];
}

// -------------------------- next level -------------------------- //

nextLevelButton.addEventListener( 'click', function() {
    var nextLevel = getNextLevel();
    if ( nextLevel ) {
        loadLevel( nextLevel );
    }
});

// -------------------------- utils -------------------------- //

function normalizeAngle( angle ) {
    return ( ( angle % TAU ) + TAU ) % TAU;
}
